#!/usr/bin/env python2

# Create a series of folders mapping to a range of time points in a year,
# for each day in a given year, each predefined hour in the day is mapped to
# a folder.
#
# Written by: Li Jiapeng
#
# The rest of the code is placed in public domain.

import abc
import ConfigParser
import copy
import datetime
import os
import os.path
import shutil
import StringIO
import threading
import tkFileDialog
import Tkinter


def yield_days(year):
    day = datetime.datetime(year, 1, 1)
    while day.year == year:
        yield day
        day += datetime.timedelta(days=1)


def days_in_year(year):
    return 366 if is_leap(year) else 365


def is_leap(year):
    return (year % 400 == 0) or (year % 100 != 0 and year % 4 == 0)


class FolderCreator(object):
    def __init__(self, root_dir, path_format):
        self.root_dir = root_dir
        self.path_format = path_format

    def create_folder(self, time_point):
        path = self.get_folder_path(time_point)
        if not os.path.isdir(path):
            os.makedirs(path)

    def get_folder_path(self, time_point):
        return os.path.join(
            self.root_dir, self.path_format.format(t=time_point)
        )


class DataParser(object):
    def __init__(self, data_type):
        if data_type == "path_format":
            self.decode = self.decode_path_format
            self.encode = self.encode_path_format
        elif data_type == "hours":
            self.decode = self.decode_hours
            self.encode = self.encode_hours
        else:
            raise ValueError("Invalid data type " + data_type)

    def decode_path_format(self, text):
        return os.path.sep.join(text.split(','))

    def decode_hours(self, text):
        hours = []
        for hour_str in text.split(','):
            try:
                hour = int(hour_str)
            except ValueError:
                raise ValueError("Invalid hour specification " + text)

            if hour < 0 or hour > 23:
                raise ValueError("Invalid hour %d in %s" % (hour, text))

            hours.append(hour)

        return hours

    def encode_path_format(self, value):
        return ','.join(value.split(os.path.sep))

    def encode_hours(self, value):
        return ','.join(map(str, value))


class ObservableMixin(object):
    def add_observer(self, observer):
        self._observers.append(observer)

    def remove_observer(self, observer):
        if observer in self._observers:
            self._observers.remove[observer]

    def notify_observers(self, *args, **kwargs):
        for observer in self._observers:
            observer.on_notified(*args, **kwargs)

    def __getattr__(self, name):
        if name == '_observers':
            self._observers = []
            return self._observers

 
class ObserverMixin(object):
    __metaclass__ = abc.ABCMeta
      
    @abc.abstractmethod
    def on_notified(self, *args, **kwargs):
        pass


class FCModel(ObservableMixin):
    def __init__(self, root_dir, path_format, year, hours):
        self.root_dir = root_dir
        self.path_format = path_format
        self.year = year
        self.hours = tuple(hours)

    def create_folders(self):
        folder_creator = FolderCreator(self.root_dir, self.path_format)
        processed = 0
        total = len(self.hours) * days_in_year(self.year)
        for time_point in self.yield_time_points():
            folder_creator.create_folder(time_point)
            processed += 1
            self.notify_observers({'processed': processed, 'total': total})

    def yield_time_points(self):
        for day in yield_days(self.year):
            for hour in self.hours:
                yield day + datetime.timedelta(hours=hour)


class MyConfigParser(ConfigParser.RawConfigParser):
    def write(self, fp, encoding='utf-8'):
        """Write an .ini-format representation of the configuration state."""

        def write_section(section, items):
            fp.write("[%s]\n" % section)
            for (key, value) in items:
                if (section != ConfigParser.DEFAULTSECT) and (key == "__name__"):
                    continue
                elif (value is None) and (self._optcre != self.OPTCRE):
                    fp.write(key + '\n')
                else:
                    write_key_value(key, value)
            fp.write("\n")

        def write_key_value(key, value):
            fp.write(b"%s = %s\n" % (
                key.encode(encoding),
                value.replace('\n', '\n\t').encode(encoding),
            ))

        if self._defaults:
            write_section(DEFAULTSECT, self._defaults.items())

        for section in self._sections:
            write_section(section, self._sections[section].items())


class FCConfig(object):
    defaults = {
        'model': {
            'root_dir': 'C:',
            'path_spec': '%Y-%m-%d,%H',
            'year': str(datetime.datetime.now().year),
            'hours_spec': '0,12',
        },
        'message': {
            'greetings': 'Welcome!',
            'root_dir_label': 'Root Directory:',
            'path_spec_label': 'Path Format:',
            'year_label': 'Year:',
            'hours_label': 'Hours:',
            'run_label': 'Create Directory',
            'quit_label': 'Quit',
            'title': 'Folder Creator',
            'sample_path': 'Sample Path:',
        }
    }

    def __init__(self, path):
        self.config_path = path
        # MyConfigParser inherits from ConfigParser.RawConfigParser
        #
        # * Interpolation is disabled, good for "path_spec" (containing "%")
        # * Allow specifying encoding in "write"
        self._config = MyConfigParser()
        self.load()

    def load(self):
        try:
            with open(self.config_path, 'r') as f:
                buf = f.read().decode('utf-8')
        except IOError:
            return

        bom_char = u'\ufeff'
        if buf.startswith(bom_char):
            buf = buf[1:]
        new_file = StringIO.StringIO(buf)
        self._config.readfp(new_file)
        new_file.close()

    def get(self, section, name):
        try:
            value = self._config.get(section, name)
        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError) as e:
            try:
                value = FCConfig.defaults[section][name]
            except KeyError:
                raise KeyError('Option %s->%s not found' % (section, name))

        self.set(section, name, value)

        return value

    def set(self, section, name, value):
        if not self._config.has_section(section):
            self._config.add_section(section)
        self._config.set(section, name, value)

    def save(self):
        with open(self.config_path, 'w') as f:
            self._config.write(f)


class FCWindow(Tkinter.Frame, ObserverMixin):
    def __init__(self, master=None):
        Tkinter.Frame.__init__(self, master)
        self.pack(fill=Tkinter.BOTH, expand=True)

        self.config = FCConfig('fc.ini')

        self.root_dir = Tkinter.StringVar(
            value=self.config.get('model', 'root_dir')
        )
        self.path_spec = Tkinter.StringVar(
            value=self.config.get('model', 'path_spec')
        )
        self.year_str = Tkinter.StringVar(
            value=self.config.get('model', 'year')
        )
        self.hours_spec = Tkinter.StringVar(
            value=self.config.get('model', 'hours_spec')
        )
        self.status = Tkinter.StringVar(
            value=self.config.get('message', 'greetings')
        )

        self.root_dir.trace("w", self.on_root_dir_change)
        self.path_spec.trace("w", self.on_path_spec_change)
        self.year_str.trace("w", self.on_year_str_change)
        self.hours_spec.trace("w", self.on_hours_spec_change)

        self.model = FCModel(
            root_dir=self.root_dir.get(),
            path_format=DataParser("path_format").decode(self.path_spec.get()),
            year=int(self.year_str.get()),
            hours=DataParser("hours").decode(self.hours_spec.get())
        )
        self.model.add_observer(self)

        self.model_lock = threading.Lock()

        self.create_root_dir_selector(
            self.config.get('message', 'root_dir_label'), self.root_dir
        )
        self.create_input(
            self.config.get('message', 'path_spec_label'), self.path_spec
        )
        self.create_input(
            self.config.get('message', 'year_label'), self.year_str
        )
        self.create_input(
            self.config.get('message', 'hours_label'), self.hours_spec
        )
        self.create_buttons([
            (self.config.get('message', 'run_label'), self.run),
            (self.config.get('message', 'quit_label'), self.exit),
        ])
        self.create_status_bar(self.status)
        self.set_title(self.config.get('message', 'title'))

    def set_title(self, title):
        self.master.title(title)

    def create_root_dir_selector(self, caption, value):
        hbox = self.create_input(caption, value)

        dir_select_button = Tkinter.Button(
            hbox, text='...', pady=0, command=self.select_root_dir
        )
        dir_select_button.pack(side=Tkinter.RIGHT)

    def select_root_dir(self):
        root_dir = os.path.normpath(tkFileDialog.askdirectory(
            initialdir=self.root_dir.get(),
            mustexist=False,
            parent=self
        ))
        self.set_root_dir(root_dir)

    def set_root_dir(self, root_dir):
        # "Cancel" leaves empty string to root_dir, do not use it
        if root_dir:
            self.root_dir.set(root_dir)

    def create_input(self, caption, value):
        hbox = Tkinter.Frame(self)
        hbox.pack(side=Tkinter.TOP, fill=Tkinter.X, expand=True)

        label = Tkinter.Label(hbox, text=caption)
        label.pack(side=Tkinter.LEFT)

        entry = Tkinter.Entry(hbox, textvariable=value)
        entry.pack(side=Tkinter.LEFT, fill=Tkinter.X, expand=True)

        return hbox

    def create_buttons(self, button_options):
        hbox = Tkinter.Frame(self)
        hbox.pack(side=Tkinter.TOP, fill=Tkinter.X, expand=True)

        for caption, command in button_options:
            box = Tkinter.Frame(hbox)
            box.pack(side=Tkinter.LEFT, fill=Tkinter.X, expand=True)
            button = Tkinter.Button(
                box, text=caption, command=command
            )
            button.pack()

        return hbox

    def create_status_bar(self, value):
        status_bar = Tkinter.Label(self, textvariable=value)
        status_bar.pack(fill=Tkinter.X, expand=True)
        return status_bar

    def run(self):
        def run_model():
            if self.model_lock.acquire(False):
                self.model.create_folders()
                self.model_lock.release()
        task_thread = threading.Thread(target=run_model)
        task_thread.start()

    def exit(self):
        self.config.save()
        self.quit()

    def on_notified(self, progress):
        processed = progress['processed']
        total = progress['total']
        if processed == total:
            self.set_status("Done!")
        else:
            self.set_status("Processing: %.0f" % (100.0 * processed / total))

    def set_status(self, string):
        self.status.set(string)

    def on_root_dir_change(self, name, index, mode):
        root_dir = self.root_dir.get()
        self.model.root_dir = root_dir
        self.config.set('model', 'root_dir', root_dir)
        self.show_sample_path()

    def on_path_spec_change(self, name, index, mode):
        path_spec = self.path_spec.get()
        self.model.path_format = DataParser("path_format").decode(path_spec)
        self.config.set('model', 'path_spec', path_spec)
        self.show_sample_path()

    def on_year_str_change(self, name, index, mode):
        year_str = self.year_str.get()
        self.model.year = int(year_str)
        self.config.set('model', 'year', year_str)
        self.show_sample_path()

    def on_hours_spec_change(self, name, index, mode):
        hours_spec = self.hours_spec.get()
        try:
            self.model.hours = DataParser("hours").decode(hours_spec)
        except ValueError as e:
            self.set_status(str(e))
        else:
            self.config.set('model', 'hours_spec', hours_spec)
            self.show_sample_path()

    def show_sample_path(self):
        folder_creator = FolderCreator(
            self.model.root_dir, self.model.path_format
        )
        try:
            sample_path = folder_creator.get_folder_path(datetime.datetime(
                self.model.year, 1, 1, self.model.hours[0]
            ))
        except ValueError as e:
            self.set_status(str(e))
        else:
            self.set_status(u"%s%s" % (
                self.config.get('message', 'sample_path'), sample_path
            ))

def main_gui():
    app = FCWindow()
    app.mainloop()


if __name__ == "__main__":
    main_gui()
